O churn trata da perda de clientes sofrida por uma empresa para concorrência, ou seja, é uma medida da infidelidade dos clientes. Vários setores da economia tem que administrar o índice de churn de seus clientes. Bancos e administradoras de cartão de crédito são exemplos bem conhecidos, bem como empresas de telcomunicações.
Há três tipos de churn: involuntário, voluntário e inevitável.
Voluntário - quando o cliente decide mudar de fornecedor, seduzido por campanhas de marketing e/ou promoções.
Inevitável - quando o usuário vem a falecer ou muda-se para uma localidade não atendida pelo fornecedor.
É provado que o custo para se manter um cliente é muito menor que o de se conquistar um novo cliente. Para se evitar o churn, empregam-se ferramentas de mineração de dados e estatística multivariada. Estas ferramentas permitem que se analise o banco de dados com informações do perfil histórico de cada usuário e que se determine quais clientes são leais, quais são propensos ao churn e quais são realmente de alto valor para a empresa.
Fonte: Andrade (2007)
Cada linha representa um cliente, cada coluna contém os atributos do cliente descritos na coluna Metadados.
O conjunto de dados inclui informações sobre:
| Coluna | Descrição |
|---|---|
| Customer ID | Código de identificação do cliente |
| gender | Indica se o cliente é masculino ou feminino |
| SeniorCitizen | Indica se o cliente é idoso (> 65 anos) ou não |
| Partner | Indica se o cliente tem um parceiro ou não |
| Dependents | Indica se o cliente tem dependentes ou não |
| tenure | Número de meses que o cliente permaneceu na empresa |
| PhoneService | Indica se o cliente tem um serviço de telefone ou não |
| MultipleLines | Indica se o cliente possui múltiplas linhas ou não |
| InternetService | Provedor de serviços de Internet do cliente |
| OnlineSecurity | Indica se o cliente tem serviço de segurança online ou não |
| Online Backup | Indica se o cliente possui um serviço de backup adicional oferencido pela empresa |
| DeviceProtection | Indica se o cliente se inscreveu em um plano de proteção de dispositivos adicional |
| TechSupport | Indica se o cliente se inscreveu em um plano de suporte técnico adicional |
| StreamingTV | Indica se o cliente usa um serviço de streaming de programação de televisão pela internet provido por terceiros (A empresa não cobra taxa adicional por esse serviço) |
| StreamingMovies | Indica se o cliente usa um serviço de streaming de filmes pela internet provido por terceiros (A empresa não cobra taxa adicional por esse serviço) |
| Contract | Indica o atual tipo de contrato do cliente |
| PaperlessBilling | Indica se o cliente escolheu o faturamento sem papel |
| PaymentMethod | Indica como o cliente paga a fatura |
| MonthlyCharges | Indica a cobrança mensal total atual do cliente por todos os serviços da empresa. |
| TotalCharges | Indica as cobranças totais do cliente, calculadas até o final do trimestre especificado |
| Churn | Indica se o cliente deixou ou não a empresa |
# importação da biblioteca pandas para manipulação do dataframe
import pandas as pd
# obtenção dos dados
fonte = r'C:\Users\Andre\Desktop\Telco\WA_Fn-UseC_-Telco-Customer-Churn.csv'
O download do arquivo de dados pode ser feito nesse link.
dados = pd.read_csv(fonte)
# visualização das primeiras 5 linhas dos dados
dados.head()
# importação das bibliotecas
import numpy as np
from matplotlib import pyplot as plt
import seaborn as sns
import plotly.express as px
import scikitplot as skplt
import sklearn.metrics as metrics
import warnings
warnings.filterwarnings('ignore')
# definindo algumas funções para plot de gráficos
# gráfico de barras
def grafbarra(x=1, y=2, dados=None,
rotulox=None, rotuloy=None,
xticklabels=None, alturay=1):
g = sns.catplot(x=x, y=y,
data=dados, saturation=.6,
kind="bar", ci=None, aspect=1, height=6)
(g.set_axis_labels(rotulox, rotuloy)
.set_xticklabels(xticklabels)
.set_titles("{col_name} {col_var}")
.set(ylim=(0, alturay))
.despine(left=True))
#histogramas
def histograma(dados=None, x=None, nbins=20):
fig = px.histogram(dados, x=x, nbins=nbins)
fig.show()
# boxplot
def boxplot(dados=None, x=None, y=None, title_y=None, title_x=None, titulograf=None):
fig = px.box(dados, x=x, y=y)
# Update yaxis properties
fig.update_yaxes(title_text=title_y , row=1, col=1)
# Update xaxis properties
fig.update_xaxes(title_text=title_x, row=1, col=1)
# Update size and title
fig.update_layout(autosize=True, width=750, height=600,
title_font=dict(size=25, family='Courier'),
title=titulograf,
)
fig.show()
# gráfico de correlação
def plot_corr(dados):
plt.figure(figsize=(15,15))
corr = dados.corr()
sns.heatmap(corr, annot=True)
dados.info()
# Verificando se há registros duplicados no dadaset.
a = dados.duplicated().sum()
print(f'Há um total de {a} dados duplicados.')
# Verificando se há registros nulos
dados.isna().sum()
Primeiramente, realizaremos mudança do tipo de variável categórica para númerica em algumas colunas para melhor análise.
# visualizando a primeira linha do dataframe
dados.head(1)
# listando as colunas do dataframe
dados.columns.values
# utilizando o método replace do pandas para mudança das variáveis categóricas para númericas
dados.Partner.replace({'Yes': 1, 'No': 0}, inplace=True)
dados.Dependents.replace({'Yes': 1, 'No': 0}, inplace=True)
dados.PhoneService.replace({'Yes': 1, 'No': 0}, inplace=True)
dados.OnlineSecurity.replace({'Yes': 1, 'No': 0, 'No internet service': 0 }, inplace=True)
dados.OnlineBackup.replace({'Yes': 1, 'No': 0, 'No internet service': 0}, inplace=True)
dados.DeviceProtection.replace({'Yes': 1, 'No': 0, 'No internet service': 0}, inplace=True)
dados.TechSupport.replace({'Yes': 1, 'No': 0, 'No internet service': 0}, inplace=True)
dados.StreamingTV.replace({'Yes': 1, 'No': 0, 'No internet service': 0}, inplace=True)
dados.StreamingMovies.replace({'Yes': 1, 'No': 0, 'No internet service': 0}, inplace=True)
dados.PaperlessBilling.replace({'Yes': 1, 'No': 0}, inplace=True)
dados.gender.replace({'Female': 1, 'Male': 0}, inplace=True)
dados.head(1)
Na célula abaixo, criaremos uma coluna chamada 'serviços_adic' em um novo dataframe, com a soma dos serviços adicionais contradados por cada cliente. O objetivo é verificar se a contratação de serviços adicionais pelo cliente faz com que ele mantenha o contrato com a empresa.
conta_serv = dados.loc[:, ['OnlineSecurity', 'OnlineBackup', 'DeviceProtection', 'TechSupport', 'StreamingTV', 'StreamingMovies']]
conta_serv['serviços_adic'] = conta_serv.sum(axis=1)
conta_serv.head()
# separando as colunas de soma dos serviços_adic e Churn em um novo dataframe
churn_serv = pd.concat([conta_serv['serviços_adic'], dados['Churn']], axis=1)
churn_serv.head()
grafbarra(x="Churn", y="serviços_adic", dados=churn_serv, rotulox='Churn', rotuloy='Serviços adicionais', alturay=2.5)
O gráfico da célula acima mostra que, os clientes com mais serviços contratados mantiveram seu plano com a empresa.
# definindo o nome das linhas da coluna 'gender' de 0 para homem e 1 para mulher
genero = dados['gender'].map({0:'Homem', 1:'Mulher'})
churn = dados['Churn'].map({'No': 0, 'Yes':1})
genero_churn = pd.concat([dados['gender'], churn], axis=1)
genero_churn.head()
total = (genero_churn['Churn'].sum()/len(genero_churn))*100
print(f'Média aritmética dos clientes perdidos pela empresa: {round(total, 2)} %')
grafbarra(x='gender', y='Churn', dados=genero_churn, rotulox='Gênero', rotuloy='Churn', xticklabels=('Homem', 'Mulher'), alturay=0.3)
O gráfico acima mostra que não há grandes disparidades entre os sexos dos clientes que deixaram a empresa.
grafbarra(x="Churn", y="PhoneService", dados=dados, rotulox='Churn', rotuloy='Serviço telefônico contratado', alturay=1)
O gráfico acima indica que não há grande influência dos clientes que possuiam, ou não, linha telefônica, na decisão de deixar a empresa.
grafbarra(y='SeniorCitizen', x='Churn', dados=dados, rotuloy='SeniorCitizen', rotulox='Churn', alturay=0.3)
A maioria dos clientes maiores que 65 anos deixaram a empresa.
boxplot(dados, x='Churn', y='MonthlyCharges', title_x='Churn', title_y='Pagamento por mês', titulograf='Pagamento por mês x Churn')
boxplot(dados, x='Churn', y='tenure', title_x='Churn', title_y='Tempo de permanência do cliente (Meses) por mês', titulograf='Tempo de permanência x Churn')
dados.columns
# transformando as variável categóricas restantes em variável númericas utilizando o OneHotEncoder
from sklearn.preprocessing import OneHotEncoder
ohe = OneHotEncoder(sparse=False)
dados['InternetService'] = ohe.fit_transform(dados[['InternetService']])
dados['Contract'] = ohe.fit_transform(dados[['Contract']])
dados['MultipleLines'] = ohe.fit_transform(dados[['MultipleLines']])
dados['PaymentMethod'] = ohe.fit_transform(dados[['PaymentMethod']])
#visualizando as primeiras 5 linhas do dataframe
dados.head()
# utilizando o método replace para alterar a coluna Churn de catégorica para numérica
dados.Churn.replace({'Yes': 1, 'No': 0}, inplace=True)
dados['TotalCharges'] = pd.to_numeric(dados.TotalCharges, errors='coerce')
# verificando se há dados ausentes no dataframe
dados.isnull().sum()
# como há poucos dados ausentes (11) no dataframe, remove-se essas linhas utilizando o método dropna()
dados = dados.dropna()
# verificando novamente se há dados ausentes
dados.isnull().sum()
# Utilizando o método Normalizer para normalização dos dados que estão em escalas diferentes de 0-1
from sklearn.preprocessing import Normalizer
dados_normal = dados[['tenure', 'MonthlyCharges' , 'TotalCharges']]
transformer = Normalizer().fit(dados_normal)
c = transformer.transform(dados_normal)
c = pd.DataFrame(c)
# concatenando o dataframe 'c' com o dataframe 'dados'
dados = pd.concat([dados, c], axis=1)
dados = dados.drop(columns=['tenure', 'MonthlyCharges' , 'TotalCharges'])
dados.head(0)
#renomeando as colunas 0, 1 e 2
dados.rename(columns={0: "tenure", 1: "MonthlyCharges", 2: 'TotalCharges'}, inplace=True)
# removendo a coluna 'customerID' por ser irrelevante para o treinamento do modelo
dados = dados.drop(columns=['customerID'])
dados.columns
# ajustando ordem das colunas para melhor visualização
dados = dados[['gender', 'SeniorCitizen', 'Partner', 'Dependents', 'PhoneService',
'MultipleLines', 'InternetService', 'OnlineSecurity', 'OnlineBackup',
'DeviceProtection', 'TechSupport', 'StreamingTV', 'StreamingMovies',
'Contract', 'PaperlessBilling', 'PaymentMethod', 'tenure',
'MonthlyCharges', 'TotalCharges', 'Churn']]
# Dataframe final
dados.head(5)
# plotando histogramas
dados.hist(figsize=(20,16))
plt.show()
# plotando gráfico de correlação entre os dados
plot_corr(dados)
# listagem do índice de correlação das colunas com a coluna 'Churn' por ordem decrescente
dados.corr().abs()['Churn'].sort_values(ascending = False)
dados = dados.dropna()
# separando a variável alvo ('Churn') do dataframe
X = dados.drop(columns='Churn')
y = dados['Churn']
# utilizando o método train_test_split para realizar a separação do dados em treino e teste. Com proporção de 70% para treino 30% para teste
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state = 40)
Utilizaremos o método de validação cruzada com KFold para selecionar, comparativamente, o melhor modelo de machine learning.
# criando uma função para comparar os modelos de classificação utilizando o método de validação cruzada com KFold
from sklearn.model_selection import KFold
from sklearn.model_selection import cross_val_score
from sklearn.linear_model import LogisticRegression
from sklearn import svm
from sklearn.neighbors import KNeighborsClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.naive_bayes import GaussianNB
from sklearn import linear_model
from sklearn.linear_model import SGDClassifier
from sklearn.ensemble import AdaBoostClassifier
from sklearn.ensemble import GradientBoostingClassifier
import xgboost as xgb
from sklearn.metrics import accuracy_score
def ApplyesKFold(X, y):
kfold = KFold(n_splits=10, shuffle=True) # shuffle=True, Shuffle (embaralhar) os dados.
# Models instances.
Logistic_Regression = LogisticRegression(max_iter=500)
KNN = KNeighborsClassifier()
DTree = DecisionTreeClassifier()
Gaussian = GaussianNB()
SVM = svm.SVC()
SGD = SGDClassifier(loss="hinge", penalty="l2", max_iter=1000)
RFC = RandomForestClassifier(n_estimators=1000)
AdaBoost = AdaBoostClassifier(n_estimators=100, random_state=0)
GradientBoosting = GradientBoostingClassifier(n_estimators=100, learning_rate=1.0, max_depth=1, random_state=0)
XGBClassifier = xgb.XGBClassifier()
# Aplica KFold aos modelos.
Logistic_Regression_result = cross_val_score(Logistic_Regression, X, y, cv = kfold, scoring='recall')
KNN_result = cross_val_score(KNN, X, y, cv = kfold, scoring='recall')
DTree_result = cross_val_score(DTree, X, y, cv = kfold, scoring='recall')
Gaussian_result = cross_val_score(Gaussian, X, y, cv = kfold, scoring='recall')
SVM_result = cross_val_score(SVM, X, y, cv = kfold, scoring='recall')
SGD_result = cross_val_score(SGD, X, y, cv = kfold, scoring='recall')
RFC_result = cross_val_score(RFC, X, y, cv = kfold, scoring='recall')
AdaBoost_result = cross_val_score(AdaBoost, X, y, cv = kfold, scoring='recall')
GradientBoosting_result = cross_val_score(GradientBoosting, X, y, cv = kfold, scoring='recall')
XGBClassifier_result = cross_val_score(XGBClassifier, X, y, cv = kfold, scoring='recall')
# Cria um dicionário para gravar o resultado dos modelos
dic_models = {
"Logistic_Regression": Logistic_Regression_result.mean(),
"KNN": KNN_result.mean(),
"DTree": DTree_result.mean(),
"Gaussian": Gaussian_result.mean(),
"SVM": SVM_result.mean(),
"SGD": SGD_result.mean(),
"RFC": RFC_result.mean(),
"AdaBoost": AdaBoost_result.mean(),
"GradientBoosting": GradientBoosting_result.mean(),
"XGBClassifier": XGBClassifier_result.mean()
}
# Seleciona o melhor modelo
bestModel = max(dic_models, key=dic_models.get)
porc = round(dic_models[bestModel]*100, 4)
print(f"Logistic_Regression Mean (R^2): {Logistic_Regression_result.mean()} \nKNN Mean (R^2): {KNN_result.mean()} \nDTree Mean (R^2): {DTree_result.mean()} \nGaussian Mean (R^2): {Gaussian_result.mean()} \nSVM_result Mean (R^2): {SVM_result.mean()}")
print(f"SGD_result Mean (R^2): {SGD_result.mean()} \nRFC_result Mean (R^2): {RFC_result.mean()} \nAdaBoost_result Mean (R^2): {AdaBoost_result.mean()} \nGradientBoosting Mean(R^2): {GradientBoosting_result.mean()} \nXGBClassifier Mean (R^2): {XGBClassifier_result.mean()}")
print("----------------------------------------------------------")
print(f"O melhor modelo é {bestModel}, com recall: {porc} %")
# executando a função de comparação de modelos
ApplyesKFold(X_train, y_train)
O modelo com maior recall para esses dados é o Gaussian Naive Bayes.
Utilizaremos esse modelo para se ajustar aos dados de treino e realizar as predições nos dados de teste.
# ajuste do modelo aos dados de treino
gaussian = GaussianNB()
gaussian.fit(X_train, y_train)
print(f'O score do modelo treinado é: {round((gaussian.score(X_train, y_train)*100),2)} %')
# realizando predição utilizando os dados de teste
y_pred = gaussian.predict(X_test)
Para realizar a avaliação das classificações feitas pelo modelo, utilizaremos a matriz de confusão. Essa matriz mostra as frequências de classificação para cada classe do modelo.
Onde:
Verdadeiro positivo (true positive — TP): ocorre quando no conjunto de teste, a classe que estamos buscando foi prevista corretamente.
Falso positivo (false positive — FP): ocorre quando no conjunto de teste, a classe que estamos buscando prever foi prevista incorretamente.
Falso verdadeiro (true negative — TN): ocorre quando no conjunto de teste, a classe que não estamos buscando prever foi prevista corretamente.
Falso negativo (false negative — FN): ocorre quando no conjunto de teste, a classe que não estamos buscando prever foi prevista incorretamente.
# plotando a matriz de confusão
skplt.metrics.plot_confusion_matrix(y_test, y_pred, normalize=True)
Nesta matriz de confusão, verifa-se que 77% dos exemplos que são da classe 0 foram preditas corretamente. Assim como, 66% dos exemplos da classe 1 foram classificados corretamente.
Para demonstrar o desempenho do modelo podemos plotar a curva ROC (Receiver Operating Characteristic) por meio da relação da Taxa de Verdadeiro Positivo (Sensibilidade) e da Taxa de Falso Positivo, variando o threshold (ponto de corte na probabilidade estimada).
fonte: OpenEye Scientific
# plot do gráfico roc
metrics.plot_roc_curve(gaussian, X_test, y_test)
plt.show()
Da matriz de confusão, podemos verificar algumas métricas de classificação do modelo nos dados.
# Obtendo métricas de classificação
# Sem desbalaceamento dos dados
# Utilizando GaussianNB
from sklearn.metrics import classification_report
print(classification_report(y_test, y_pred, output_dict=False))
Plotando o gráfico de contagem da variável alvo, percebe-se que há um desbalanceamento dos dados.
Por questão de comparação, foi implementado duas técnicas de over-sampling para desbalanceamento nos dados de treino (SMOTE e ADASYN), e realizou-se predição com modelos selecionados através da técnica de cross-validation.
# plotando gráfico de contagem da variável alvo
sns.countplot(y)
O resultado dos testes é apresentado abaixo.
Métricas de classificação com método SMOTE para desbalancemanto dos dados
# SMOTE
# Utilizando modelo SVM
precision recall f1-score support
0.0 0.83 0.82 0.83 1547
1.0 0.52 0.52 0.52 560
accuracy 0.74 2107
macro avg 0.67 0.67 0.67 2107
weighted avg 0.74 0.74 0.74 2107
Métricas de classificação com método ADASYN para desbalancemanto dos dados
#ADASYN
#Utilizando modelo Random Forest
precision recall f1-score support
0.0 0.82 0.83 0.83 1547
1.0 0.52 0.49 0.51 560
accuracy 0.74 2107
macro avg 0.67 0.66 0.67 2107
weighted avg 0.74 0.74 0.74 2107
Comparando os resultados, verifica-se que os valores de recall e f1-score dos modelos utilizados com as técnicas de desbalanceamento de dados, são piores na classe 1 do que o modelo utilizado com os dados desbalanceados.
Entretanto, o recall da classe 0 do modelo GaussianNB utilizado com os dados desbalanceados é razoavelmente pior do que os outros dois modelos testados.
Apesar disso, o f1-score do modelo treinado com dados desbalanceados é melhor na classe 1, além de ter a mesma acurácia dos outros modelos. Por isso, definiu-se o Gaussian Naive Bayes como modelo final.
import pickle
# salvar o modelo GaussianNB (gaussian) no arquivo churn_gaussian.pkl
with open('churn_gaussian.pkl', 'wb') as file:
pickle.dump(gaussian, file)
Com este projeto foi possível verificar que os dados apresentados trazem algumas informações que podem traçar o perfil médio dos clientes que tendem a sair da empresa.
Clientes com mais serviços adicionais contratados e com mais tempo de contrato tendem a manter seu vínculo. Entretanto, clientes mais novos, maiores que 65 anos e com os maiores valores de planos, são propensos a deixar a empresa.
Por outro lado, os dados mostram que não há grandes disparidades entre os sexos dos clientes que deixaram a empresa, além de indicar que não há grande influência dos clientes que possuiam, ou não, linha telefônica, na decisão de deixar a empresa.
Por fim, foi implementado um modelo de machine learning com acurária de 74% para prever quais clientes que ainda tem vínculo com empresa são propensos em finalizar seu plano.